토큰 기반 인증
토큰 기반 인증이란?
토큰 기반 인증은 사용자가 로그인하면 서버가 토큰(Token)을 발급하고, 클라이언트는 이 토큰을 저장한 후 요청할 때 마다 이를 서버로 보내어 인증을 받는 방식이다. 이 방식은 Stateless(무상태)한 인증 방식으로, 서버가 사용자의 세션 상태를 기억할 필요가 없다.
동작 원리
- 로그인 요청: 사용자가 자격 증명(예: 사용자 이름, 비밀번호)을 서버에 제출한다.
- 토큰 발급: 서버는 자격 증명이 유효하면, 클라이언트에게 JWT(JSON Web Token)와 같은 토큰을 발급한다.
- 토큰 저장: 클라이언트는 이 토큰을 브라우저의 로컬 스토리지나 쿠키에 저장한다.
- 요청 시 토큰 전송: 이후 클라이언트는 모든 요청에 이 토큰을 HTTP 헤더에 포함하여 서버로 전송한다.
- 서버에서 토큰 검증: 서버는 전송된 토큰의 유효성을 확인하고, 유효한 경우 요청을 처리한다.
- 토큰 만료/재발급: 토큰이 만료되면, 클라이언트는 새로 로그인하거나 리프레시 토큰(refresh token)을 사용해 새로운 액세스 토큰(access token)을 발급받는다.
JWT(JSON Web Token)
JWT(JSON Web Token)는 JSON 형식으로 데이터를 안전하게 주고 받기 위한 웹 표준이다. JWT는 크게 세 부분으로 나뉜다.
- Header (헤더): 토큰의 타입(JWT)과 서명에 사용할 해싱 알고리즘(예: HS256)을 정의한다.
- Payload (페이로드): 사용자 정보나 기타 데이터를 담고 있으며, 이는 암호화되지 않은 상태로 Base64로 인코딩 된다.
- Signature (서명): 헤더와 페이로드를 조합하고 비밀키로 서명한 값이다. 이 서명을 통해 데이터의 무결성을 검증할 수 있다.
JWT 구조
- https://jwt.io/
위 링크로 이동하면 JWT의 형태를 볼 수 있다. JSON 웹 토큰은 헤더, 페이로드, 서명 세 부분으로 구성된다. 헤더와 페이로드는 Base64로 인코딩된 다음 마침표로 연결된다.
header.payload.signature
// 토큰 전체
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // 여기가 헤더
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
// 여기까지가 페이로드
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // 서명 부분이다
토큰 기반 인증의 장단점
장점
- 무상태(Stateless): 서버가 세션 상태를 유지할 필요가 없으므로 확장성(Scalability)에 유리하다.
- 다양한 플랫폼에서 사용 가능: 모바일 앱, 웹 애플리케이션 등 다양한 환경에서 쉽게 사용할 수 있다.
- 보안성 강화: 각 요청마다 토큰이 포함되므로, 세션 탈취 공격(Session Hijacking)을 줄일 수 있다.
단점
- 보안 문제: JWT가 탈취되면 만료될 때까지 악용될 수 있으므로 안전하게 저장해야 한다.
- 토큰 크기 문제: JWT는 서명과 페이로드를 포함하므로 쿠키보다 크기가 커질 수 있으며, 대역폭을 더 많이 차지할 수 있다.
- 만료된 토큰 처리 복잡성: 만료된 토큰을 처리하는 로직이 복잡할 수 있다.
코드 예시
설정 및 패키지 설치
mkdir jwt-auth-example
cd jwt-auth-example
npm init -y
npm install express jsonwebtoken body-parser
- express: Node.js에서 서버를 쉽게 구축할 수 있는 프레임워크
- jsonwebtoken: JWT 생성 및 검증을 위한 라이브러리
- body-parser: POST 요청에서 보낸 데이터를 파싱하기 위한 미들웨어
app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
const SECRET_KEY = 'mySecretKey'; // JWT 서명에 사용할 비밀키
// 간단한 사용자 데이터베이스 예시
const users = {
'user1': 'password1',
'user2': 'password2'
};
// 로그인 페이지 (GET 요청)
app.get('/login', (req, res) => {
res.send(`
<h2>로그인</h2>
<form method="POST" action="/login">
<label>사용자 이름:</label>
<input type="text" name="username" />
<label>비밀번호:</label>
<input type="password" name="password" />
<button type="submit">로그인</button>
</form>
`);
});
// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 사용자 인증 확인
if (users[username] && users[username] === password) {
// JWT 생성 (유효기간 1시간)
const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token });
} else {
res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
}
});
// 보호된 라우트 (JWT 검증)
app.get('/dashboard', (req, res) => {
const token = req.headers['authorization'];
if (!token) {
return res.status(403).send('토큰이 필요합니다.');
}
// JWT 검증
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(401).send('유효하지 않은 토큰입니다.');
}
// 유효한 토큰일 경우 사용자 정보 제공
res.send(`환영합니다, ${decoded.username}님!`);
});
});
// 서버 실행
app.listen(3000, () => {
console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
실행
node app.js
- 미리 정의된 사용자 정보(user1, password1) 을 로그인 폼에 입력
- 토큰이 화면에 표시
- 표시된 토큰을 https://jwt.io/ 링크를 통해 디코딩하면 정보를 확인할 수 있음
토큰 저장
실제 애플리케이션에서는 이 토큰을 로컬 스토리지나 쿠키에 저장하여 이후 요청 시 사용할 수 있도록 한다.
localStorage.setItem('token', token);
보호된 경로 접근 시 토큰 전송
클라이언트는 보호된 경로(예: dashboard)에 접근할 때마다 이 토큰을 HTTP 요청의 Authorization 헤더에 포함시켜 서버로 전송해야 한다.
저장 및 대시보드 이동 예시
서버 코드(app.js)
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const app = express();
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(express.static('public')); // 정적 파일 제공을 위해 public 폴더 사용
const SECRET_KEY = 'mySecretKey'; // JWT 서명에 사용할 비밀키
// 간단한 사용자 데이터베이스 예시
const users = {
'user1': 'password1',
'user2': 'password2'
};
// 로그인 페이지 (GET 요청)
app.get('/login', (req, res) => {
res.sendFile(__dirname + '/public/login.html'); // 로그인 페이지 제공
});
// 로그인 처리 (POST 요청)
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 사용자 인증 확인
if (users[username] && users[username] === password) {
// JWT 생성 (유효기간 1시간)
const token = jwt.sign({ username }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token }); // JWT 토큰 반환
} else {
res.status(401).send('로그인 실패! 사용자 이름 또는 비밀번호가 잘못되었습니다.');
}
});
// 보호된 라우트 (JWT 검증)
app.get('/dashboard', (req, res) => {
const authHeader = req.headers['authorization'];
if (!authHeader) {
return res.status(403).send('토큰이 필요합니다.');
}
const token = authHeader.split(' ')[1];
// JWT 검증
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(401).send('유효하지 않은 토큰입니다.');
}
// 유효한 토큰일 경우 사용자 정보 제공
res.send(`환영합니다, ${decoded.username}님!`);
});
});
// 서버 실행
app.listen(3000, () => {
console.log('서버가 http://localhost:3000 에서 실행 중입니다.');
});
로그인페이지(public/login.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인</title>
</head>
<body>
<h2>로그인</h2>
<form id="loginForm">
<label>사용자 이름:</label>
<input type="text" id="username" />
<label>비밀번호:</label>
<input type="password" id="password" />
<button type="submit">로그인</button>
</form>
<script>
document.getElementById('loginForm').addEventListener('submit', async function(event) {
event.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
throw new Error('로그인 실패');
}
const data = await response.json();
// JWT를 로컬 스토리지에 저장 - 추가된 부분
localStorage.setItem('token', data.token);
alert('로그인 성공! 대시보드로 이동합니다.');
// 대시보드로 이동 - 추가된 부분
window.location.href = '/dashboard.html';
} catch (error) {
alert(error.message);
}
});
</script>
</body>
</html>
대시보드페이지(public/dashboard.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드</title>
</head>
<body>
<h2>대시보드</h2>
<p id="welcomeMessage"></p>
<script>
async function loadDashboard() {
const token = localStorage.getItem('token'); // 로컬 스토리지에서 토큰 가져오기
if (!token) {
alert('로그인이 필요합니다.');
window.location.href = '/login.html';
return;
}
try {
const response = await fetch('/dashboard', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token // Authorization 헤더에 토큰 추가
}
});
if (!response.ok) {
throw new Error('대시보드 접근 실패');
}
const message = await response.text();
document.getElementById('welcomeMessage').innerText = message;
} catch (error) {
alert(error.message);
}
}
loadDashboard();
</script>
</body>
</html>
dashboard.html 을 보면 headers의 토큰 앞에 Bearer를 추가한 것을 볼 수 있다.
Bearer
Bearer는 OAuth 2.0 인증 프레임워크에서 사용하는 토큰 인증 방식 중 하나다. 이 방식에서 Bearer 토큰은 보호된 리소스에 접근할 수 있는 권한을 부여하는 엑세스 토큰의 일종이다.
Bearer의 의미
- Bearer는 "소유자"라는 뜻이다. 즉, Bearer 토큰은 "이 토큰을 소유한 사람에게 권한을 부여해줘"라는 의미를 내포하고 있다.
- Bearer 토큰은 클라이언트가 서버에 요청을 보낼 때, HTTP 요청의 Authorization헤더에 포함되어 전송된다.
헤더에서 Bearer 사용
- HTTP 요청에서 Bearer 토큰을 전송할 때, Authorization 헤더에 다음과 같은 형식으로 포함한다.
Authorization: Bearer <token>
예시
fetch('/dashboard', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
})
Bearer 사용하는 이유
- Bearer는 서버에서 "이 토큰을 가진 사람은 인증된 사용자이므로, 이 사용자가 보호된 리소스에 접근할 수 있도록 해달라"는 의미를 전달한다.
- 이 방식은 OAuth 2.0에서 주로 사용되며, 클라이언트가 서버에 다시 로그인하지 않고도 보호된 리소스에 접근할 수 있도록 한다.
사실 Bearer 없어도 됨
- Bearer 없이도 JWT를 사용할 수 있지만, 일반적으로 OAuth 2.0 및 여러 표준에서는 Bearer 방식을 권장한다.
- Bearer 없이도 JWT를 Authorization 헤더에 포함시켜 서버로 전송할 수 있지만, 보안 표준과 명확한 의사소통을 위해 Bearer 방식을 사용하는 것이 더 안전하고 일관성 있는 방법이다.
서버 측 코드
서버 측에서 Bearer를 이용한 JWT를 검증할 때, Authorization 헤더에서 Bearer 토큰을 추출하여 검증한다. 이 과정에서 헤더가 올바르게 전달되지 않으면 인증 실패로 처리된다.
// Authorization 헤더 가져오기
const authHeader = req.headers['authorization'];
// Bearer 뒤의 실제 토큰만 추출
const token = authHeader.split(' ')[1];
위 코드에서 authHeader.split(' ')[1]은 Authorization 헤더에서 "Bearer"를 제외한 실제 JWT 토큰 부분만 추출하는 코드이다.
그렇기에 "Bearer " 뒤에 한 칸의 공백을 반드시 포함시켜야 한다.
블로그 내 관련 문서
참고 자료
출처 :